昨天留下的問題其實很不舒服:沒有答案卷時,誰來說 OCR 對不對?
最省事的答案是讓產生結果的模型再讀一次。也是我不採用的答案。Day 5 已經把這個坑叫出名字:同源盲點。同一個模型若把「阻」讀成「防」,第二輪很可能仍覺得句子通順;它檢查到的是自己的敘事,而不是字形。
規劃裡把 gpt-oss:20b 放在 Verifier 位置,理由很單純:它不是 Google 家的模型,跟負責 OCR 的 gemma-4-26b 不同源。但寫到這裡我停下來問自己一個更笨的問題——我到底有沒有辦法真的把它叫起來?昨天寫完才發現,我對這顆模型的規格其實一無所知,也沒認真算過自己手上的硬體吃不吃得下。今天先把這筆帳算清楚,再決定 Verifier 的介面該長什麼樣。
查了 Hugging Face 官方模型卡(huggingface.co/openai/gpt-oss-20b):總參數 21B,MoE 架構,每個 token 實際只啟用 3.6B。原生量化格式是 MXFP4(4-bit block-scaled),官方卡上寫的是「run within 16GB of memory」;另外交叉查了 Unsloth 文件、apxml、willitrunai 等幾個獨立來源,都落在同一個量級——MXFP4 權重實際落地大約 14GB,官方建議的最低顯存門檻是 16GB。專家數量與 top-k 路由細節,官方頁面和 arXiv 摘要(2508.10925)都沒寫,我查不到,就不編。
順手換算一下如果不用 MXFP4:FP16 需要 21B × 2 bytes ≈ 42GB,INT8 需要 21B × 1 byte ≈ 21GB。MXFP4 之所以能塞進 16GB,是因為它是專門為這代模型設計的 4-bit 格式,不是我拿 4-bit 粗略估的位元數乘一乘就能算對的東西——這行是我自己按官方數字回推的,不是官方文件裡的原始算式,算是「驗證過官方結論、但沒驗證官方怎麼算出來的」。
本機:RTX 3050 Laptop,4096 MiB VRAM,實測 nvidia-smi 目前 0 已用。16GB 建議門檻對 4GB 顯卡來說不是「差一點」,是差了一個數量級,GPU 推論這條路直接關掉。
CPU/RAM 混合推論呢?我這台總記憶體 15.6 GiB(wmic 查到 16,782,102,528 bytes,除以 1024³)。Ollama 支援 CPU offload,理論上 14GB 的權重加上作業系統、context、KV cache,會把 15.6 GiB 幾乎吃光——算式上勉強卡在邊緣,但這是「紙上算得過去」,不是「我跑過」。零緩衝空間的配置,我沒有把握它不會在載入到一半時被系統砍掉,也沒有時間預算去賭這件事,所以老實說:我沒試。
GB10(10.0.0.220:8004):128GB 統一記憶體,273 GB/s 頻寬,Day 3 量過。容量上 16GB 對 128GB 是綽綽有餘,看起來像最合理的選項。但兩個現實問題擋在前面:第一,Day 3 已經踩過統一記憶體的坑——host 層的 Ollama 跑 gemma4:26b 時,跟同時在跑的 vLLM container 搶記憶體直接 crash(journalctl 顯示 llama runner segfault),Day 3 的結論是「一次只起一個」。這台機器現在線上跑的就是 gemma-4-26b,OCR 主流程還在用它,我不能因為想測 Verifier 就把生產端點打掉。第二,也是更直接的一點:這台機器的操作權限不在我這裡,我只能打 API,沒辦法登進去換模型或另開一個 instance。
Day 3 推過一個公式:理論上限 tok/s ≈ 記憶體頻寬 ÷ 每個 token 需要讀取的權重量。gpt-oss:20b 每個 token 只動用 3.6B 活躍參數,MXFP4 大約 0.5 byte/參數:
活躍權重 = 3.6B × 0.5 byte ≈ 1.8 GB
理論上限(GB10)= 273 GB/s ÷ 1.8 GB ≈ 152 tok/s
跑不動歸跑不動,Verifier 這個角色要接什麼、吐什麼,可以先訂死。訂這份契約用得上 Day 5 的教訓:Verifier 不該是「再問模型一次通不通順」,它要回報的是具體疑點,而且要老實承認自己也會不確定。
寫進了 D:\iron-people\harness\verifier_protocol.py,用 Pydantic v2 定義四個東西:
class VerifierVerdict(str, Enum):
PASS = "PASS"
FLAG = "FLAG" # 有疑點但不確定,需要人工或第三方複核
FAIL = "FAIL"
class VerifierIssue(BaseModel):
location: str
original_text: str
concern: str
confidence: float = Field(..., ge=0.0, le=1.0)
VerifierVerdict 刻意做成三態而不是二元的 PASS/FAIL。二元判定逼著模型在不確定的時候硬選一邊,FLAG 給它一個「我看到怪但不確定」的出口——這件事本身也是為了避免同源盲點的另一種變形:如果 Verifier 因為輸出格式所迫必須表態,它在不確定時很可能傾向選 PASS(阻力最小的答案),那就跟沒驗證一樣。VerifierIssue.confidence 加了 ge=0.0, le=1.0 的範圍驗證,這條我實際測過會擋下超出範圍的值:
VerifierIssue(location="test", original_text="test", concern="test", confidence=1.5)
→ pydantic_core._pydantic_core.ValidationError:
Input should be less than or equal to 1
VerifierResponse.model_id 這個欄位是專門留給稽核用的:以後不管接的是 gpt-oss:20b 還是別的模型,這裡都要老實記下「這次判定實際用了哪個模型」,避免有人(包括我自己)不小心把 Verifier 換成跟主模型同源的東西卻沒發現。目前它的值寫死成 "gpt-oss:20b (design-only, not yet wired)"——這個字串本身就是在承認「這只是設計,還沒真的接上」,demo 資料不能偽裝成真實推論結果。
跑一下 __main__ 區塊的 demo(純本地物件序列化,沒有任何網路呼叫):
{
"verdict": "FLAG",
"issues": [
{
"location": "p.3 表格區塊 2 / 金額欄",
"original_text": "NT$1,2O0",
"concern": "數字中疑似混入字母 O 取代數字 0,金額判讀可能有誤",
"confidence": 0.82
}
],
"model_id": "gpt-oss:20b (design-only, not yet wired)",
"latency_ms": 0.0
}
Verifier 只回報判定與疑點,原始 OCR 輸出保持不動;判定用三態(PASS/FLAG/FAIL)而不是二元,讓模型有老實說「不確定」的空間;model_id 欄位強制記錄實際用的模型,防止同源盲點在稽核層被悄悄繞過。